column-privileges.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { LOCAL_STORAGE_KEYS, useParams } from 'common'
  2. import { AlertCircle, XIcon } from 'lucide-react'
  3. import Link from 'next/link'
  4. import { useCallback, useEffect, useMemo, useState } from 'react'
  5. import { toast } from 'sonner'
  6. import { Alert, AlertDescription, AlertTitle, Button } from 'ui'
  7. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  8. import {
  9. useFeaturePreviewModal,
  10. useIsColumnLevelPrivilegesEnabled,
  11. } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
  12. import {
  13. getDefaultColumnCheckedStates,
  14. getDefaultTableCheckedStates,
  15. useApplyPrivilegeOperations,
  16. usePrivilegesState,
  17. } from '@/components/interfaces/Database/Privileges/Privileges.utils'
  18. import PrivilegesHead from '@/components/interfaces/Database/Privileges/PrivilegesHead'
  19. import PrivilegesTable from '@/components/interfaces/Database/Privileges/PrivilegesTable'
  20. import { ProtectedSchemaWarning } from '@/components/interfaces/Database/ProtectedSchemaWarning'
  21. import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
  22. import DefaultLayout from '@/components/layouts/DefaultLayout'
  23. import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
  24. import AlertError from '@/components/ui/AlertError'
  25. import { DocsButton } from '@/components/ui/DocsButton'
  26. import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
  27. import { PgRole, useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
  28. import { useColumnPrivilegesQuery } from '@/data/privileges/column-privileges-query'
  29. import { useTablePrivilegesQuery } from '@/data/privileges/table-privileges-query'
  30. import { useTablesQuery } from '@/data/tables/tables-query'
  31. import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
  32. import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
  33. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  34. import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
  35. import { DOCS_URL } from '@/lib/constants'
  36. import type { NextPageWithLayout } from '@/types'
  37. const EDITABLE_ROLES = ['authenticated', 'anon', 'service_role']
  38. const PrivilegesPage: NextPageWithLayout = () => {
  39. const { ref, table: paramTable } = useParams()
  40. const { data: project } = useSelectedProjectQuery()
  41. const { toggleFeaturePreviewModal } = useFeaturePreviewModal()
  42. const isEnabled = useIsColumnLevelPrivilegesEnabled()
  43. const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
  44. const [selectedTable, setSelectedTable] = useState<string | undefined>(paramTable)
  45. const [selectedRole, setSelectedRole] = useState<string>('authenticated')
  46. const {
  47. data: tableList,
  48. isPending: isLoadingTables,
  49. isSuccess: isSuccessTables,
  50. } = useTablesQuery({
  51. projectRef: project?.ref,
  52. connectionString: project?.connectionString,
  53. })
  54. useEffect(() => {
  55. if (!isSuccessTables) return
  56. const tables = tableList
  57. .filter((table) => table.schema === selectedSchema)
  58. .map((table) => table.name)
  59. if (tables[0] && selectedTable === undefined) {
  60. setSelectedTable(tables[0])
  61. }
  62. }, [isSuccessTables, tableList, selectedSchema, selectedTable])
  63. const { data: allRoles, isPending: isLoadingRoles } = useDatabaseRolesQuery({
  64. projectRef: project?.ref,
  65. connectionString: project?.connectionString,
  66. })
  67. const tables = tableList
  68. ?.filter((table) => table.schema === selectedSchema)
  69. .map((table) => table.name)
  70. const {
  71. data: allTablePrivileges,
  72. isPending: isLoadingTablePrivileges,
  73. isError: isErrorTablePrivileges,
  74. error: errorTablePrivileges,
  75. } = useTablePrivilegesQuery({
  76. projectRef: project?.ref,
  77. connectionString: project?.connectionString,
  78. })
  79. const tablePrivilege = useMemo(() => {
  80. const tablePrivilege = allTablePrivileges?.find(
  81. (tablePrivilege) =>
  82. tablePrivilege.schema === selectedSchema && tablePrivilege.name === selectedTable
  83. )
  84. if (tablePrivilege) {
  85. return {
  86. ...tablePrivilege,
  87. privileges: tablePrivilege.privileges.filter(
  88. (privilege) => privilege.grantee === selectedRole
  89. ),
  90. }
  91. }
  92. }, [allTablePrivileges, selectedRole, selectedSchema, selectedTable])
  93. const {
  94. data: allColumnPrivileges,
  95. isPending: isLoadingColumnPrivileges,
  96. isError: isErrorColumnPrivileges,
  97. error: errorColumnPrivileges,
  98. } = useColumnPrivilegesQuery({
  99. projectRef: project?.ref,
  100. connectionString: project?.connectionString,
  101. })
  102. const columnPrivileges = useMemo(
  103. () =>
  104. allColumnPrivileges
  105. ?.filter(
  106. (privilege) =>
  107. privilege.relation_schema === selectedSchema &&
  108. privilege.relation_name === selectedTable
  109. )
  110. .map((privilege) => ({
  111. ...privilege,
  112. privileges: privilege.privileges.filter(
  113. (privilege) => privilege.grantee === selectedRole
  114. ),
  115. })) ?? [],
  116. [allColumnPrivileges, selectedRole, selectedSchema, selectedTable]
  117. )
  118. const rolesList = allRoles?.filter((role: PgRole) => EDITABLE_ROLES.includes(role.name)) ?? []
  119. const roles = rolesList.map((role: PgRole) => role.name)
  120. const table = tableList?.find(
  121. (table) => table.schema === selectedSchema && table.name === selectedTable
  122. )
  123. const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
  124. const {
  125. tableCheckedStates,
  126. columnCheckedStates,
  127. toggleTablePrivilege,
  128. toggleColumnPrivilege,
  129. operations,
  130. resetOperations,
  131. } = usePrivilegesState(
  132. useMemo(
  133. () => ({
  134. tableId: table?.id ?? -1,
  135. role: selectedRole,
  136. defaultTableCheckedStates: tablePrivilege
  137. ? getDefaultTableCheckedStates(tablePrivilege)
  138. : {},
  139. defaultColumnCheckedStates: getDefaultColumnCheckedStates(columnPrivileges),
  140. }),
  141. [columnPrivileges, selectedRole, table?.id, tablePrivilege]
  142. )
  143. )
  144. const hasChanges = operations.length > 0
  145. const handleChangeSchema = (schema: string) => {
  146. if (hasChanges) {
  147. if (window.confirm('You will lose your changes. Are you sure?')) {
  148. resetOperations()
  149. } else {
  150. return
  151. }
  152. }
  153. const newTable = tableList?.find((table) => table.schema === schema)?.name
  154. setSelectedSchema(schema)
  155. setSelectedTable(newTable)
  156. }
  157. const handleChangeTable = (table: string) => {
  158. if (hasChanges) {
  159. if (window.confirm('You will lose your changes. Are you sure?')) {
  160. resetOperations()
  161. } else {
  162. return
  163. }
  164. }
  165. setSelectedTable(table)
  166. }
  167. const handleChangeRole = (role: string) => {
  168. setSelectedRole(role)
  169. }
  170. const { apply: applyColumnPrivileges, isLoading: isApplyingChanges } =
  171. useApplyPrivilegeOperations(
  172. useCallback(() => {
  173. toast.success(
  174. `Successfully updated privileges on ${selectedSchema}.${selectedTable} for ${selectedRole}`,
  175. { duration: 6000 }
  176. )
  177. resetOperations()
  178. }, [resetOperations, selectedRole, selectedSchema, selectedTable])
  179. )
  180. function applyChanges() {
  181. applyColumnPrivileges(operations)
  182. }
  183. const [diffWarningDismissed, setDiffWarningDismissed] = useLocalStorage(
  184. LOCAL_STORAGE_KEYS.CLS_DIFF_WARNING,
  185. false
  186. )
  187. const [selectStarWarningDismissed, setSelectStarWarningDismissed] = useLocalStorage(
  188. LOCAL_STORAGE_KEYS.CLS_SELECT_STAR_WARNING,
  189. false
  190. )
  191. const isLoading =
  192. isLoadingTablePrivileges || isLoadingColumnPrivileges || isLoadingTables || isLoadingRoles
  193. const isError = isErrorTablePrivileges || isErrorColumnPrivileges
  194. return (
  195. <ScaffoldContainer className="h-full">
  196. <ScaffoldSection className="h-full">
  197. <div className="col-span-12 flex flex-col pb-4 gap-y-4">
  198. <div className="flex items-center justify-between">
  199. <div>
  200. <div className="flex items-center gap-x-4 mb-1">
  201. <h3 className="text-xl">Column-level privileges</h3>
  202. {isEnabled && (
  203. <FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS} />
  204. )}
  205. </div>
  206. <p className="text-sm text-lighter">
  207. Grant or revoke privileges on a column based on user role.
  208. </p>
  209. </div>
  210. <DocsButton href={`${DOCS_URL}/guides/auth/column-level-security`} />
  211. </div>
  212. {isEnabled ? (
  213. <>
  214. {!diffWarningDismissed && (
  215. <Alert variant="warning">
  216. <AlertCircle strokeWidth={2} />
  217. <AlertTitle>
  218. Changes to column privileges will not be reflected in migrations when running{' '}
  219. <code className="text-code-inline">briven db diff</code>.
  220. </AlertTitle>
  221. <AlertDescription>
  222. Column privileges are not supported in the current version of the Briven CLI.
  223. <br />
  224. You will need to manually apply these changes to your database.
  225. </AlertDescription>
  226. <Button
  227. type="outline"
  228. aria-label="Dismiss"
  229. className="absolute top-2 right-2 p-1 pl-1!"
  230. onClick={() => {
  231. setDiffWarningDismissed(true)
  232. }}
  233. >
  234. <XIcon width={14} height={14} />
  235. </Button>
  236. </Alert>
  237. )}
  238. {!selectStarWarningDismissed && (
  239. <Alert variant="warning">
  240. <AlertCircle strokeWidth={2} />
  241. <AlertTitle>Changing column privileges can break existing queries.</AlertTitle>
  242. <AlertDescription>
  243. If you remove a column privilege for a role, that role will lose all access to
  244. that column.
  245. <br />
  246. All operations selecting <code className="text-code-inline">
  247. *
  248. </code> (including <code className="text-code-inline">returning *</code> for{' '}
  249. <code className="text-code-inline">insert</code>,{' '}
  250. <code className="text-code-inline">update</code>, and{' '}
  251. <code className="text-code-inline">delete</code>) will fail.
  252. </AlertDescription>
  253. <Button
  254. type="outline"
  255. aria-label="Dismiss"
  256. className="absolute top-2 right-2 p-1 pl-1!"
  257. onClick={() => {
  258. setSelectStarWarningDismissed(true)
  259. }}
  260. >
  261. <XIcon width={14} height={14} />
  262. </Button>
  263. </Alert>
  264. )}
  265. <PrivilegesHead
  266. disabled={isSchemaLocked}
  267. selectedSchema={selectedSchema}
  268. selectedRole={selectedRole}
  269. selectedTable={table}
  270. tables={tables ?? []}
  271. roles={roles}
  272. onChangeSchema={handleChangeSchema}
  273. onChangeRole={handleChangeRole}
  274. onChangeTable={handleChangeTable}
  275. applyChanges={applyChanges}
  276. resetChanges={resetOperations}
  277. hasChanges={hasChanges}
  278. isApplyingChanges={isApplyingChanges}
  279. />
  280. {isSchemaLocked && (
  281. <ProtectedSchemaWarning schema={selectedSchema} entity="column privileges" />
  282. )}
  283. {isLoading ? (
  284. <GenericSkeletonLoader />
  285. ) : isError ? (
  286. <AlertError error={errorTablePrivileges || errorColumnPrivileges} />
  287. ) : table && tablePrivilege ? (
  288. <div>
  289. <PrivilegesTable
  290. disabled={isSchemaLocked}
  291. columnPrivileges={columnPrivileges}
  292. tableCheckedStates={tableCheckedStates}
  293. columnCheckedStates={columnCheckedStates}
  294. toggleTablePrivilege={toggleTablePrivilege}
  295. toggleColumnPrivilege={toggleColumnPrivilege}
  296. isApplyingChanges={isApplyingChanges}
  297. />
  298. </div>
  299. ) : (tables ?? []).length === 0 ? (
  300. <div className="grow flex flex-col items-center justify-center w-[600px] mx-auto">
  301. <p className="text-center">There are no tables in the {selectedSchema} schema</p>
  302. <p className="text-sm text-foreground-light text-center">
  303. Once a table is available in the schema, you may manage it's column-level
  304. privileges here
  305. </p>
  306. {selectedSchema === 'public' && (
  307. <Button asChild className="mt-4">
  308. <Link href={`/project/${ref}/editor`}>Create a new table</Link>
  309. </Button>
  310. )}
  311. </div>
  312. ) : (
  313. <div className="flex flex-col items-center justify-center h-64 ">
  314. <p className="text-foreground-light">Select a table to edit privileges</p>
  315. </div>
  316. )}
  317. </>
  318. ) : (
  319. <Alert>
  320. <AlertTitle>Column-level privileges is a dashboard feature preview</AlertTitle>
  321. <AlertDescription>
  322. You may access this feature by enabling it under dashboard feature previews.
  323. </AlertDescription>
  324. <div className="mt-4">
  325. <Button type="default" onClick={() => toggleFeaturePreviewModal(true)}>
  326. View feature previews
  327. </Button>
  328. </div>
  329. </Alert>
  330. )}
  331. </div>
  332. </ScaffoldSection>
  333. </ScaffoldContainer>
  334. )
  335. }
  336. PrivilegesPage.getLayout = (page) => (
  337. <DefaultLayout>
  338. <DatabaseLayout title="Column Privileges">{page}</DatabaseLayout>
  339. </DefaultLayout>
  340. )
  341. export default PrivilegesPage